查看原文
其他

有道词典Android客户端包体积优化之路

Bug总柴 Bug总柴 2023-02-13

导读:今天来围观下我们团队在包体积优化上面做的努力吧!


1 背景

有道词典从移动互联网之初就凭借小巧快速、功能强大的印象让用户爱上翻译查词,爱上学习。随着业务不断地迭代以及功能不断完善,有道词典不再是单纯的查词软件,而是变成了用户的综合学习平台。我们探索过社区、问答、直播、信息流等业务,目前也承载着音频、视频、课程、背单词、写作批改等等的功能。词典已经发展成为一个综合性的学习平台,小巧快速的初心仍然指引着我们不断进行启动速度以及包体积优化。

经过了不断的性能优化,目前我们的冷启动时间已经能维持在业界标准水平3s以内。我们近一个季度主要的性能优化工作集中在安装包体积优化上面。经过一系列的努力,我们包体积减少了23.7%,安装包体积从177MB减少到135MB,整体少了42MB。

以下详细介绍我们的分析以及实现细节。

2 分析

介绍下包体积包含的内容以及优化方法概述

一般的APK安装包包含了以下一些目录和资源:

  • META-INF/ 签名文件

  • assets/ 程序使用的辅助资源文件

  • res/ 没有编译进入resources.arsc 资源文件,一般是图片

  • lib/ 依赖的不同native平台的库文件

  • resource.arsc 编译之后的文案、色值、大小、主题等资源索引

  • classes.dex 编译后的代码

  • AndroidMenifest.xml 应用的名称、版本、访问权限和引用的库文件信息


可以看出占比较大的部分主要是分别是assets/lib/res/classes.dex以及resources.arsc,大概对应的就是资源、库文件、代码以及资源索引。我们主要的优化思路如下(其中蓝色框部分为目前已经处理部分):


3 技术实现细节

3.1 图片压缩

在APK打包的过程中,aapt 工具会默认对图片进行无损压缩,不过默认的压缩并不能达到一个很好的压缩效果,经过了对比webp以及tinypng的压缩效果,我们最终选择了使用tinypng对图片进行压缩。并且我们编写了编译工具,对图片进行自动化压缩。

有损webp > tinypng > 无损webp



比如这张启动图,原大小724KB,压到75%左右的质量只有23.7KB。效果上有一点点差异,但可以接受。那么我们是否可以把全部png图压成有损webp呢?答案是否定的,可以看看下面的例子:



压缩前:



压缩后:



可以看到,相同的压缩质量下(75%),这个图就变得十分模糊,哪怕选择到了99%的压缩质量,渐变区域依然会出现一些没有自然过渡的条纹。


对于上述的情况,用tinypng方案更好

原图:643KB,

tinypng: 152KB,

webp:339KB

综上,对于有损webp,无法找到一个固定的压缩质量来适配所有场景。有损webp有些时候甚至比tinypng还大,但显示质量更差。

我们最初使用的抖音的McImage插件对图片进行处理,不过这个方案存在一些明显的问题:

  1. 方案采用有损webp,有损webp无法定一个通用的压缩质量适应所有场景。

  2. 每次打包都要对所有图进行压缩,严重影响迭代效率。打包机要40分钟,且经常OOM。

  3. 没有对assets目录的图片进行处理。

针对以上问题,我们自己开发了一套使用tinypng的自动化图片压缩工具,做出以下调整:

  1. 对于大图png,用手工压成有损webp。收益大,且风险可控。

  2. 对于非大图,开发了一个image-optimization插件进行压缩。该插件方案为:

  • png转tinypng。虽然是有损的,但从抽样来看,肉眼完全看不到明显变化。

  • 对assets进行处理。assets内有前端png图,转tinypng不转webp的好处是不需要单独改html、js等文件,且对低版本系统兼容性更友好。flutter相关项目的flutter_assets图片比较大且没注意压缩。插件统一处理可以不需要打开flutter工程单独优化、重新打包。

  • 对于已压缩的图片,做缓存处理,不需要重新压缩,打包的时候动态替换。压缩缓存跟随词典工程提交到gitlab统一管理。

以下是我们图片自动化压缩插件处理的流程图:


这里压缩图是否可用判断,主要是大小判断,如果压出来比原图大,那么将舍弃。比如crunchPng压缩就存在这种情况

附加1:因为已经用了tinypng统一压缩,那么google官方自带的crunchPng建议关闭,否则打包速度变慢,而且优化好的图片也可能又变大,加入这行即可:

buildTypes.all {  isCrunchPngs = false}

附加2:无损webp和tinypng对比

如图所示,全量换tinypng比全量换webp(包含assets)少7.7MB。如果考虑到assets内的14.7MB其实是不能简单换webp的,差距会更大。



附加3:tinypng已经是最好的方案吗?

参考另一个ImageOptim工具,它结合OptiPNG, PNGCrush, AdvanceComp, PNGOUT, Jpegoptim + Jpegtran, 和 Gifsicle 等几个工具提供最好的优化效果,而且是几乎无损的。对于小部分图片ImageOptim压出来小,看起来没有差别。不过压缩速度非常慢。

所以,如果做到极致的话,可以进行多种压缩方案,选最佳的图作为替换。且我们的image-optimization插件从一开始设计的时候就预留了这种可扩展性。

附加4:AndResGuard优化对比

试了一下效果不明显,且出现部分资源丢失而崩溃的情况。效果不明显的原因,猜测是目前R8对资源名也有混淆压缩(以前proguard没有),所以AndResGuard现在的作用比较微弱。至于7zip的压缩没有开,理论上会导致启动速度变慢,觉得得不偿失(另外会导致Google Pay的Patch优化算法失效)。

3.2 resources.arsc优化

  • 语言包优化


打开resources.arsc的string,我们可以看到如下表格,会发现大量空的地方(如上图)。这些空白的地方,其实是用FF FF FF…字符进行占位的,占用了很多空间(如下图)。由于有道词典没有进行国际化翻译(有一个国际化版本叫U-Dictionary,欢迎支持),因此删掉不必要的语言版本有助于减少体积。



android { defaultConfig { resConfigs "zh" }}
  • 如上所示,增加一行,保留中文即可。收获比想象中大,直接减少了3MB。

  • dimens优化查看了最近几个版本的arsc体积,发现有一个版本增加了5MB。

    在这个版本我们做了平板适配功能,由于我们采用的是SmallestWith限定符适配方案(可以先了解下这个屏幕适配方案),因此产生大量的尺寸资源。



一共是有3000多个资源,每一个资源有“values-sw300dp”到"values-sw1200dp"共90个版本,这块存在较大的优化空间。

sqb_px_xx”这一项是用于字体适配的,但词典用到最大的字体是“sqb_px_144”,所以优化了生成规则,减少了这一类资源。

优化后,资源数量由3012变成1662,减少了近一半。直接减少了2.5MB。

3.3 业务代码删除

由于Proguard以及lint等工具是从代码引用的角度进行分析和代码裁剪,如果一些废弃的代码不先进行删除会影响后续工作的效果。对于一些已经废弃没有入口的业务,不进行处理的话那么代码、资源会只增不减。业务删减应该是所有包体积流程的第一步,否则后面的去掉无用资源、图片压缩、混淆等等效果都要打一个折扣。如果时间有限的话,那么删最近的需求会比删远古时代的需求收益会大点,原因是越靠近现在的项目,图片资源、字体资源,以及用到so库都会比较大(尤其是音视频)。

这部分工作主要是对业务功能的整理以及沟通部分陈旧业务是否可以进行删除,除此之外就是需要细致的引用分析将废弃业务相关代码剥离出来进行删除。

一个良好的项目架构对于日后业务代码的剥离有很大好处。目前新开发的功能我们采用的是分层分模块的组织架构,功能模块之间不存在相互依赖,因此以后对于业务的抽离或者删除会更加方便。


3.4 无用资源删除

对于无用资源删除我们主要使用了两个方法,一个是通过 lint 工具找到应用中可能没有使用的资源并逐一进行判断确认没有使用后进行删除,第二个是在build.gradle文件中加入shrinkResources在编译阶段使用R8工具进行删除

buildTypes { release { // Zipalign优化 zipAlignEnabled true // 移除无用的resource文件 shrinkResources true // 移除没用的代码 minifyEnabled true }}

使用 lint 工具需要注意对以下一些场景进行再次判断确认

  1. 对于反射性引用资源,可能会被识别成无用资源,比如push用到的通知栏icon

  2. DataBinding用到的layout资源会被识别成无用资源

3.5 压缩混淆

使用R8工具在编译阶段对代码进行压缩混淆,从而达到压缩安装包体积的效果。主要分为以下4个步骤:

  1. 压缩(shrink) 移除未使用的类、方法、字段等;

  2. 优化(optimize) 优化字节码、简化代码等操作;

  3. 混淆(obfuscate) 使用简短的、无意义的名称重命名类名、方法名、字段等;

  4. 预校验(preverify) 为class添加预校验信息。

我们在两年前就引入了Proguard,不过考虑到混淆带来的问题使用了-dontobfuscate配置取消混淆。我们发现之前的规则中从依赖库中继承了 -dontoptimize 的配置导致优化也没有生效。这次优化中,我们全面解决了混淆带来的众多问题,全面开启了优化以及混淆。

由于我们之前已经开启过了压缩,因此需要使用到的类已经在proguard中进行了保留。开启混淆后还需要处理以下一些问题:

  • getIdentifier 通过名称获取资源问题。如果是普通模式,则会自动不去掉相关资源:



  • 检查Resources.getValue 相关逻辑

  • 检查AssetManager.open相关逻辑

  • 反射,全局搜一下反射包,修改相关位置 java.lang.reflect

  • 处理Retrofit报错问题(https://github.com/square/retrofit/issues/3588),目前使用升级Gradle插件版本进行解决

Caused by: java.lang.IllegalArgumentException: Method return type must not include a type variable or wildcard: ho8<su3<?>> for method CheckInApi.popupConfig at retrofit2.Utils.methodError(SourceFile:5) at retrofit2.Utils.methodError(SourceFile:1) at retrofit2.ServiceMethod.parseAnnotations(SourceFile:7) at retrofit2.Retrofit.loadServiceMethod(SourceFile:4) at retrofit2.Retrofit$1.invoke(SourceFile:6) at java.lang.reflect.Proxy.invoke(Proxy.java:1006) at $Proxy23.popupConfig(Unknown Source) at com.youdao.dict.checkin.CheckInPopupManager.requestPopupConfig(SourceFile:3) at java.lang.reflect.Method.invoke(Native Method)

Proguard的规则会很大程度上影响R8对代码压缩和混淆带来的效果,因此对压缩规则的回顾以及整理可以帮助进一步的体积压缩。

3.6 字体优化

字体优化这部分是在之前的版本已经实现过的,取得的效果也挺明显,这里补充说明一下。

  • 字体裁剪

    一般的字体库大小会有十几二十兆。但实际上用到的字符只有很少一部分,因此针对实际的使用场景对字体库进行适当的裁剪,收益非常大。

    常用字列表:https://github.com/DavidSheh/CommonChineseCharacter

    字体压缩工具:https://github.com/forJrking/FontZip

  • 字体合并

    一般来说,我们开发都会模块化,不同的团队采用在开发不同功能的时候,有可能用到相同的字体。如果稍不注意就会复制成两份、三份,文件大大增加。词典这边的方案是把共有的字体下沉到底层core基础库,供各个模块引用。

4 展望

经过了上述的工作,目前词典的安装包体积优化了23.7%,整体减少了42MB。在接下来的Q2,我们将准备做两方面的事情。

4.1 包体积监控

在包体积优化的过程中,我们在含辛茹苦地砍掉一点体积之后,转过头来发现别的同学又随随便便扔进去几MB的大图。因此,如何坚守胜利的果实,让包体积保持最佳状态成了重中之重。

打包任务增加了是否检查包大小限制(默认都要检查) 的选项;merge request之后,词典的打包任务会触发自动构建;

打包任务完成之后,如果需要检查包大小,那就开始触发apkcheck步骤;具体如下:

  1. 打包任务完成之后增加脚本操作,把本次构建的数据(如apk文件地址,mapping文件地址,R文本地址等)写入临时文件;

  2. 打包任务构建后操作增加 Trigger parameterized build on other projects,触发apk 大小检查任务;

  3. 开始检查流程,检查流程根据参数对apk进行检查任务,并且把任务结果生成html;

4.2 动态分发

  • 整体业务分发

可以使用插件化以及动态加载等技术,不过这些可能不是最难的,最难的是如何把一些祖传的、低频的、而又相互依赖的代码抽离出来,形成独立模块去做分发、动态加载。

  • 业务子功能分发(预计可优化39.6MB)

  1. 数据库(单词锁屏8MB)

    单词锁屏可以保留几百kb数据在本地让用户备用,同时再下载完整的词库。

  2. OCR引擎数据(22.5MB)

    用户应该可以按需下载训练模型,而不是直接内置;当没有训练模型的时候,可以直接网络请求。

  3. 字体(9.1MB)

    除了查词等高频业务,低频业务的字体可以动态分发,有则显示,无则使用系统的即可。但emoji的兼容库比较特殊,主要用在首页信息流的帖子、UGC发帖等。如果没有兼容库,用户在遇到特别的emoji中可能会显示“豆腐块”,这个时候如果emoji字体库还没下载完成,需要进行替换兜底处理。另外哪怕用户系统已经有这个emoji内置字体,也有可能显示效果各个手机不太一样,需要跟UI确认一下是要替换掉,还是暂时这样显示。


保持住词典小巧快速、功能强大的初心是我们不停进行性能优化的动力,在接下来的工作中,我们会对启动速度、安装包体积以及内存占用等多方面进行持续优化和改进,欢迎大家继续关注和支持!

5 参考

  1. Reduce your app size

  2. Shrink, obfuscate, and optimize your app

  3. 抖音图片压缩插件McImage

  4. 腾讯包体积监控ApkChecker


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存